Skip to content

feat(inbound): Gmail push webhook receiver (HT-39) - #39

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-39-gmail-webhook-receiver
Jul 14, 2026
Merged

feat(inbound): Gmail push webhook receiver (HT-39)#39
zaridan merged 2 commits into
mainfrom
feat/ht-39-gmail-webhook-receiver

Conversation

@zaridan

@zaridan zaridan commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Implements HT-39 [G] — the Gmail push webhook receiver, under the HT-33 epic. This is the authenticated front door: Google Pub/Sub POSTs a push notification here, we prove it's really Google, resolve it to a connected mailbox, and hand a reconcile job to the queue for the history sync (HT-41) to drain.

What's here

  • src/providers/adapters/gmail/push-auth.tsverifyGmailPushJwt: OIDC JWT verification via jose. Checks iss (accounts.google.com), aud (our push endpoint), email (the configured push service account), email_verified === true, and expfails closed on anything missing or wrong. createGooglePushKeySource (Google's JWKS, cached by jose) and createGmailPushSignatureVerifier wire it to the InboundEmailProvider.verifySignature seam.
  • src/api/gmail-webhook.tshandleGmailPushWebhook: verify signature → parse the Pub/Sub envelope → resolve emailAddress to an active mailbox (MailboxStore.getMailboxByAddress) → enqueue a GmailReconcileJob ({ mailboxId, historyId }) to GMAIL_RECONCILE_TOPIC with a ${mailbox.id}:${historyId} dedupe key. A MAX_BODY_BYTES (64 KiB) streaming cap rejects oversized bodies before buffering.
  • Router/API wiring (src/api/router.ts, index.ts) — mounts the webhook with the push config threaded through deps.gmailPush.

Security posture

  • Uniform 403 (gmailPushRejected) for every auth-class outcome — bad/missing JWT, wrong issuer/audience/email, email_verified=false, unknown mailbox, non-active mailbox, and even "push not configured." Identical body every time, so a probing attacker gets no oracle distinguishing "wrong signature" from "unknown mailbox."
  • Infra failures are 500, not 403 — a throwing queue or mailbox store surfaces as a server error (and never leaks its message to the client), so a real outage is distinguishable from a rejected forgery in our logs without widening the client-visible surface.
  • The store returns a mailbox regardless of status; the handler applies the "must be active" policy — the storage-layer/policy split the module docs call out.

Merge note

Branched before HT-37/38 landed; I merged origin/main in and combined the two MailboxStore variants into one interface carrying both getMailboxByAddress (this ticket) and markNeedsReconnect (HT-38) — single createMailboxStore, both test suites preserved. Conflicts were src/store/{index,mailboxes,mailboxes.test}.ts only.

Verification

  • typecheck + biome clean; full suite 535 tests pass locally (single worktree, no concurrent-run contention this time).
  • Config is injected, not read from process.env here: GmailPushJwtConfig = endpointUrl (matched to the JWT aud) + serviceAccountEmail (matched to email). Binding those to real env vars is the composition root's job (HT-43).

Scope: the receiver + auth only — no history.list fetch/ingest (HT-41), no watch() lifecycle (HT-42). The enqueued reconcile job is the hand-off boundary.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an unauthenticated Gmail push webhook endpoint at /api/v1/inbound/gmail.
    • Valid push notifications enqueue mailbox reconciliation jobs automatically.
  • Bug Fixes
    • Enforced consistent webhook rejection semantics for invalid/unauthorized/malformed/oversized or inactive-mailbox requests.
    • Added fail-closed verification for Google-signed push JWTs; internal errors return a generic server error without leaking details.
  • Tests
    • Added comprehensive end-to-end and unit test coverage for routing, authentication outcomes, payload validation, job enqueuing, and error handling.

POST /api/v1/inbound/gmail — a pre-auth, OIDC-JWT-verified surface that verifies the push, resolves the mailbox, and enqueues a reconcile job onto the QueueProvider (no inline Gmail fetch; that's HT-41).

push-auth.ts (adapters/gmail): Google OIDC JWT verification via jose (createRemoteJWKSet + jwtVerify) — iss/aud/email/email_verified/exp, fails closed, JWKS-cached, kept out of src/api per the provider-boundary rule. gmail-webhook.ts: uniform 403 for every failed check (no oracle; unconfigured == rejected), streaming body-size cap, JWT-before-body-read, active-mailbox resolution, enqueue with a best-effort dedupe key. mailboxes.ts: MailboxStore.getMailboxByAddress. Pre-auth branch wired into createInboxApi before Bearer auth (mirrors the tracking pixel) + matchGmailPushWebhook. Added jose (^6, MIT, zero transitive deps).

Implements specs/mail/gmail-push.md §2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9fb2eb1d-52be-427a-bfd9-61016f4f94a4

📥 Commits

Reviewing files that changed from the base of the PR and between f1584f2 and 0a0be11.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • package.json
  • src/api/gmail-webhook.test.ts
  • src/api/gmail-webhook.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/providers/adapters/gmail/index.ts
  • src/providers/adapters/gmail/push-auth.test.ts
  • src/providers/adapters/gmail/push-auth.ts
  • src/store/index.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts
🚧 Files skipped from review as they are similar to previous changes (12)
  • package.json
  • src/providers/adapters/gmail/index.ts
  • src/store/mailboxes.test.ts
  • src/api/router.test.ts
  • src/providers/adapters/gmail/push-auth.ts
  • src/api/router.ts
  • src/api/gmail-webhook.test.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/providers/adapters/gmail/push-auth.test.ts
  • src/store/mailboxes.ts
  • src/api/gmail-webhook.ts

📝 Walkthrough

Walkthrough

Changes

Adds a Gmail Pub/Sub push webhook endpoint with Google OIDC JWT verification, strict request and payload validation, active mailbox lookup, reconciliation job enqueueing, uniform rejection responses, and pre-auth API routing. Tests cover authentication, validation, routing, and error handling.

Gmail push webhook

Layer / File(s) Summary
Mailbox lookup contract
src/store/index.ts, src/store/mailboxes.ts, src/store/mailboxes.test.ts
Mailbox lookup maps database rows to typed records, preserves status values, and matches addresses exactly.
Push JWT authentication
package.json, src/providers/adapters/gmail/*
Adds jose-based Google JWKS verification with issuer, audience, email, verification, expiration, and Bearer-token checks.
Webhook validation and reconciliation enqueueing
src/api/gmail-webhook.ts, src/api/gmail-webhook.test.ts
Validates request method, content type, size, subscription, payload, and mailbox status before enqueueing a deduplicated gmail-reconcile job.
Pre-auth API routing
src/api/index.ts, src/api/index.test.ts, src/api/router.ts, src/api/router.test.ts
Routes the exact Gmail webhook path before Bearer authentication and preserves normal authentication for other API routes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PubSub
  participant InboxApi
  participant GmailPushVerifier
  participant MailboxStore
  participant QueueProvider
  PubSub->>InboxApi: POST push envelope
  InboxApi->>GmailPushVerifier: verify request JWT
  GmailPushVerifier-->>InboxApi: verified result
  InboxApi->>MailboxStore: getMailboxByAddress
  MailboxStore-->>InboxApi: active mailbox
  InboxApi->>QueueProvider: enqueue gmail-reconcile job
  QueueProvider-->>InboxApi: enqueue result
  InboxApi-->>PubSub: 200 OK
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a Gmail push webhook receiver.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-39-gmail-webhook-receiver

Comment @coderabbitai help to get the list of available commands.

…ook-receiver

# Conflicts:
#	src/store/index.ts
#	src/store/mailboxes.test.ts
#	src/store/mailboxes.ts
@zaridan
zaridan force-pushed the feat/ht-39-gmail-webhook-receiver branch from f1584f2 to 0a0be11 Compare July 14, 2026 03:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/gmail-webhook.test.ts`:
- Around line 39-45: Update the fakeMailboxes test helper to implement the
required MailboxStore.markNeedsReconnect method alongside getMailboxByAddress,
preserving the existing mailbox lookup behavior. Also inspect the related
partial mock in the pre-auth API routing tests and add the same required method
wherever it is missing.

In `@src/api/index.test.ts`:
- Around line 1552-1560: Update fakeMailboxes in the test file to satisfy the
MailboxStore type by importing MailboxStore as a type and annotating the
helper’s returned object accordingly. Implement the required markNeedsReconnect
method alongside getMailboxByAddress, preserving the existing mailbox lookup
behavior and allowing all GmailPushDeps call sites to typecheck.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e5128f0c-8990-4942-81fc-4b901f8c31c7

📥 Commits

Reviewing files that changed from the base of the PR and between 8ae8bef and f1584f2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (13)
  • package.json
  • src/api/gmail-webhook.test.ts
  • src/api/gmail-webhook.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/providers/adapters/gmail/index.ts
  • src/providers/adapters/gmail/push-auth.test.ts
  • src/providers/adapters/gmail/push-auth.ts
  • src/store/index.ts
  • src/store/mailboxes.test.ts
  • src/store/mailboxes.ts

Comment thread src/api/gmail-webhook.test.ts
Comment thread src/api/index.test.ts
Comment on lines +1552 to +1560
function fakeMailboxes(
record: { id: string; address: string; provider: string; status: 'active' } | null,
) {
return {
async getMailboxByAddress(address: string) {
return record !== null && record.address === address ? record : null
},
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

fakeMailboxes doesn't satisfy MailboxStore — breaks typecheck at every call site.

GmailPushDeps.mailboxes requires a full MailboxStore (including markNeedsReconnect), but this helper only implements getMailboxByAddress. Static analysis confirms tsc failures at every usage: Line 1598, Line 1620, Line 1700, and Line 1720 (Property 'markNeedsReconnect' is missing ... required in type 'MailboxStore'). This would fail the typecheck CI check despite the PR's summary claiming a clean typecheck run.

🛠️ Proposed fix
     /** A `MailboxStore` fake for wiring tests that never need real persistence — always resolves to `record` (or `null`). */
     function fakeMailboxes(
       record: { id: string; address: string; provider: string; status: 'active' } | null,
-    ) {
+    ): MailboxStore {
       return {
         async getMailboxByAddress(address: string) {
           return record !== null && record.address === address ? record : null
         },
+        async markNeedsReconnect(_mailboxId: string) {
+          throw new Error('fakeMailboxes: markNeedsReconnect not implemented')
+        },
       }
     }

Also add the type import so the annotation resolves:

import type { MailboxStore } from '../store/mailboxes.js'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function fakeMailboxes(
record: { id: string; address: string; provider: string; status: 'active' } | null,
) {
return {
async getMailboxByAddress(address: string) {
return record !== null && record.address === address ? record : null
},
}
}
function fakeMailboxes(
record: { id: string; address: string; provider: string; status: 'active' } | null,
): MailboxStore {
return {
async getMailboxByAddress(address: string) {
return record !== null && record.address === address ? record : null
},
async markNeedsReconnect(_mailboxId: string) {
throw new Error('fakeMailboxes: markNeedsReconnect not implemented')
},
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/index.test.ts` around lines 1552 - 1560, Update fakeMailboxes in the
test file to satisfy the MailboxStore type by importing MailboxStore as a type
and annotating the helper’s returned object accordingly. Implement the required
markNeedsReconnect method alongside getMailboxByAddress, preserving the existing
mailbox lookup behavior and allowing all GmailPushDeps call sites to typecheck.

Source: Linters/SAST tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant